// Copyright (c) 2025 Shenzhen Kaihong Digital Industry Development Co., Ltd.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::{borrow::Cow, ffi::OsStr, fmt::Write, io::Write as IoWrite, num::ParseIntError, path::{Path, PathBuf}};

use anyhow::Context;
use futures::StreamExt;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use log::{trace, warn};
use rand::Rng;
use reqwest::{IntoUrl, Url};
use serde::{de::Visitor, Deserializer, Serializer};
use sha2::{digest::Output, Digest, Sha256};
use tokio::{io::AsyncWriteExt, process::Command};

pub const SHA256_STR_LEN: usize = (256/8)*2;
const FERRIUM_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));

pub fn random_tmp_path<const MAX_TRY: usize>() -> Option<PathBuf> {
    let mut tmp_dir = std::env::temp_dir();
    for _ in 0..MAX_TRY {
        let mut file_name = format!("{}-", env!("CARGO_CRATE_NAME"));
        rand::thread_rng()
            .sample_iter(rand::distributions::Alphanumeric)
            .take(8)
            .map(char::from)
            .for_each(|c| file_name.push(c));
        tmp_dir.push(file_name);
        if tmp_dir.exists() {
            tmp_dir.pop();
        } else {
            return Some(tmp_dir);
        }
    }
    None
}

pub fn copy_file_dereference_to<P: AsRef<Path>>(file_path: P, dst_path: P) -> std::io::Result<()> {
    let file_path = file_path.as_ref();
    let real_path = file_path.canonicalize()?;
    std::fs::copy(real_path, dst_path)?;
    Ok(())
}

pub fn copy_file_dereference<P: AsRef<Path>>(file_path: P, dst_dir: P) -> std::io::Result<()> {
    let file_path = file_path.as_ref();
    let dst_path = dst_dir.as_ref().join(file_path.file_name().unwrap());
    copy_file_dereference_to(file_path, &dst_path)?;
    Ok(())
}

fn copy_dir_all_by<P, F, T>(src: P, dst: P, f: F) -> std::io::Result<()>
where
    P: AsRef<Path>,
    F: Copy + Fn(PathBuf, PathBuf) -> std::io::Result<T>,
{
    std::fs::create_dir_all(&dst)?;

    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        let copyto = dst.as_ref().join(entry.file_name());
        if ty.is_dir() {
            copy_dir_all_by(entry.path(), copyto, f)?;
        } else {
            if copyto.exists() {
                trace!("The dst file '{}' exists", copyto.display());
                continue;
            }
            
            if ty.is_symlink() {
                let linkto = std::fs::read_link(entry.path())?;
                std::os::unix::fs::symlink(linkto, copyto)?;
            } else {
                f(entry.path(), copyto)?;
            }
        } 
    }
    Ok(())
}

pub fn copy_dir_all<P: AsRef<Path>>(src: P, dst: P) -> std::io::Result<()> {
    copy_dir_all_by(src, dst, std::fs::copy)
}

pub fn symlink_dir_all<P: AsRef<Path>>(src: P, dst: P) -> std::io::Result<()> {
    copy_dir_all_by(src, dst, std::os::unix::fs::symlink)
}

pub fn hard_link_dir_all<P: AsRef<Path>>(src: P, dst: P) -> std::io::Result<()> {
    copy_dir_all_by(src, dst, std::fs::hard_link)
}

pub fn sha256_from_str(s: &str) -> Result<Output<Sha256>, ParseIntError> {
    debug_assert!(s.len() == SHA256_STR_LEN);

    let mut bytes = [0; 256/8];
    for i in 0..bytes.len() {
        let byte = u8::from_str_radix(&s[i*2..(i+1)*2], 16)?;
        bytes[i] = byte;
    }

    Ok(bytes.into())
}

pub fn sha256_to_string(sha256: &Output<Sha256>) -> String {
    let mut s = String::with_capacity(SHA256_STR_LEN);
    for &byte in sha256 {
        s.write_fmt(format_args!("{:02x}", byte)).unwrap();
    }
    s
}

pub fn sha256_serialize<S: Serializer>(sha256: &Output<Sha256>, serializer: S) -> Result<S::Ok, S::Error> {
    serializer.serialize_str(&sha256_to_string(sha256))
}

pub fn sha256_deserialize<'de, D>(deserializer: D) -> Result<Output<Sha256>, D::Error>
where D: Deserializer<'de>
{
    struct StrVisitor;

    impl Visitor<'_> for StrVisitor {
        type Value = Output<Sha256>;
    
        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
            formatter.write_str("sha256")
        }

        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
        where
            E: serde::de::Error,
        {
            if v.len() != SHA256_STR_LEN {
                return Err(serde::de::Error::invalid_length(
                    SHA256_STR_LEN,
                    &format!("a {SHA256_STR_LEN}-chars sha256 str").as_str())
                );
            }

            sha256_from_str(v).map_err(|_| serde::de::Error::invalid_value(
                serde::de::Unexpected::Str(v),
                &"a sha256 str"
            ))
        }
    }

    deserializer.deserialize_str(StrVisitor)
}

pub async fn sha256_from_file<P: AsRef<Path>>(path: P) -> Result<Output<Sha256>, std::io::Error> {
    let content = tokio::fs::read(path).await?;
    let mut hasher = Sha256::new();
    hasher.update(content);
    Ok(hasher.finalize())
}

pub fn guess_filename_by_source(source: &str) -> &str {
    let s = match source.rsplit_once('/') {
        Some((_, right)) => right,
        None => source,
    };
    match s.strip_suffix(".git") {
        Some(filename) => filename,
        None => s
    }
}

pub async fn get_download_file_size(client: &reqwest::Client, url: Url) -> Result<u64, reqwest::Error> {
    let response = client.head(url).send().await?;
    let response = response.error_for_status()?;
    Ok(response
        .headers()
        .get(reqwest::header::CONTENT_LENGTH)
        .and_then(|len| len.to_str().ok())
        .and_then(|len| len.parse().ok())
        .unwrap_or(0))
}

pub fn download_progress_style(prefix: &str) -> Result<ProgressStyle, indicatif::style::TemplateError>  {
    let template = format!("{{spinner}} {prefix}: {{percent}}% {{bytes_per_sec}} [{{bytes}}/{{total_bytes}}] [{{elapsed}}/{{eta}}]");
    ProgressStyle::with_template(&template)
}

pub async fn download_with_progress<T, P>(url: T, path: P, multi_progress: MultiProgress, style: ProgressStyle) -> anyhow::Result<()>
where T: IntoUrl, P: AsRef<Path>
{
    let url = url.into_url()?;
    let client = reqwest::Client::builder()
        .user_agent(FERRIUM_USER_AGENT)
        .build()?;
    let file_size = match get_download_file_size(&client, url.clone()).await {
        Ok(size) => size,
        Err(err) => {
            warn!("failed to get file size of {}: {}", url, err);
            0
        },
    };
    
    let mut file = tokio::fs::OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .open(path.as_ref())
        .await
        .context(format!("failed to open file '{}'", path.as_ref().display()))?;
    let mut stream = client.get(url).send().await?.bytes_stream();

    let progress_bar = multi_progress.add(ProgressBar::new(file_size));
    progress_bar.set_style(style);
    while let Some(chunk_result) = stream.next().await {
        let chunk = chunk_result?;
        file.write_all(&chunk).await?;
        progress_bar.inc(chunk.len() as u64);
    }
    file.flush().await?;
    progress_bar.finish();

    multi_progress.remove(&progress_bar);
    
    Ok(())
}

pub fn git_clone_cmd(url: &str, path: &Path, recursive: bool) -> Command {
    let mut cmd = Command::new("git");
    cmd.arg("clone");
    if recursive {
        cmd.arg("--recursive");
    }
    cmd.arg(url);
    cmd.arg(path.as_os_str());
    cmd
}

pub fn tar_cmd(compress_flags: &str, chdir: &Path, dst: &Path) -> Command {
    let mut cmd = Command::new("tar");
    cmd.args([
        OsStr::new("-C"),
        chdir.as_os_str(),
        OsStr::new(compress_flags),
        OsStr::new("-cf"),
        dst.as_os_str(),
        OsStr::new("."),
    ]);
    cmd
}

pub fn untar_cmd(compress_flags: &str, chdir: &Path, download_path: &Path) -> Command {
    let mut cmd = Command::new("tar");
    cmd.args([
        OsStr::new("-C"),
        chdir.as_os_str(),
        OsStr::new(compress_flags),
        OsStr::new("-xf"),
        download_path.as_os_str(),
    ]);
    cmd
}

pub fn unzip_cmd(chdir: &Path, download_path: &Path) -> Command {
    let mut cmd = Command::new("unzip");
    cmd.args([
        download_path.as_os_str(),
        OsStr::new("-d"),
        chdir.as_os_str(),
    ]);
    cmd
}

pub fn unpack_cmd<'a>(chdir: &Path, download_path: &'a Path) -> Result<Command, Cow<'a, str>> {
    debug_assert!(download_path.is_file());
    let filename = download_path
        .file_name()
        .unwrap()
        .to_string_lossy();
    if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
        Ok(untar_cmd("-z", chdir, download_path))
    } else if filename.ends_with(".tar.xz") {
        Ok(untar_cmd("-J", chdir, download_path))
    } else if filename.ends_with(".tar.bz2") {
        Ok(untar_cmd("-j", chdir, download_path))
    } else if filename.ends_with(".tar.zst") {
        Ok(untar_cmd("--zstd", chdir, download_path))
    } else if filename.ends_with(".zip") {
        Ok(unzip_cmd(chdir, download_path))
    } else {
        Err(filename)
    }
}

pub fn localedef_cmd(root: &Path) -> Command {
    let mut cmd = Command::new("localedef");
    cmd
        .args([
            "-i", "C",
            "-f", "UTF-8",
        ])
        .arg(format!("--prefix={}", root.display()))
        .arg("C.UTF-8");
    cmd
}

pub fn confirm(prompt: &str) -> std::io::Result<bool> {
    loop {
        println!("{} [y/N]: ", prompt);
        std::io::stdout().flush()?;
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        let input = input.trim().to_lowercase();
        if input == "n" || input == "y" || input.is_empty() {
            return Ok(input == "y");
        } else {
            println!("Invalid input");
        }
    }
}

#[cfg(test)]
mod test {
    use serde::de::value::StringDeserializer;
    use sha2::{Digest, Sha256};

    use crate::utils::{guess_filename_by_source, sha256_deserialize, sha256_from_str, sha256_to_string};

    #[test]
    fn test_sha256_str_convert() {
        let mut hasher = Sha256::new();
        hasher.update("random");
        let sha256 = hasher.finalize();

        assert_eq!(Ok(sha256), sha256_from_str(&sha256_to_string(&sha256)));
    }

    #[test]
    fn test_sha256_str_deserialize() {
        let sha256_str = "cd328edeac92f6a665de9f323c93b712af1858bc2e0d88f3f7100469470a1b8a";
        assert_eq!(
            sha256_deserialize(StringDeserializer::<toml::de::Error>::new(sha256_str.to_string())).unwrap(),
            sha256_from_str("cd328edeac92f6a665de9f323c93b712af1858bc2e0d88f3f7100469470a1b8a").unwrap()
        );
    }

    #[test]
    fn test_guess_filename_by_source() {
        assert_eq!(guess_filename_by_source("http://example.com/foo/bar"), "bar");
        assert_eq!(guess_filename_by_source("http://example.com/foo/bar.git"), "bar");
        assert_eq!(guess_filename_by_source("/foo/bar"), "bar");
        assert_eq!(guess_filename_by_source("/foo/bar.git"), "bar");
        assert_eq!(guess_filename_by_source("foo"), "foo");
        assert_eq!(guess_filename_by_source("foo.git"), "foo");
    }
}